#!/bin/env python

"""Classes to control a simulation.

TODO: Separate GUI and logging code from models.

Copyright (C) Sarah Mount, 2005-2008.

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
"""

import consts, ranger
from Dingo.pubsub import Publisher

import copy, os, random, string
from threading import Lock
import math, Queue

__author__  = "Sarah Mount"
__credits__ = "Daniel Goldsmith"
__date__    = "2005/01/07"

DEBUG = True


class Simulation(object):
    """Superclass for sensor simulations."""
    def __init__(self, sensors, graph):
        """Initialise a new simulation.
        This will initialise a new simulation using the given list of sensors.  The Graph refers to the 
        sensorGraph object generated by the UI.

        @type sensors: list
        @param sensors: A List of sensors in the simulation
        @type graph: Dingo.OGL.SensorGraph
        @param graph: The panel used to display the simulations sensors
        """
        self.s_list = dict((s.id, s) for s in sensors)
        """@ivar: Dictionary of (id, sensor) pairs
	@type dict
        """
        self.graph = graph
        """@ivar: Graph pane in GUI
        @type: Dingo.OGL.SensorGraph
        """
        self.packetLogger = None
        """@ivar: Object to log all messages sent at run-time between sensors.
        @type: Dingo.simulation.PacketLogger
        """
        self.projectDir = None 
        """@ivar: Directory on disk where the current project is stored. Set in the L{Dingo.Main.DingoApp}
        @type str
        """
        return
    
    def get_sensor(self, s_id):
        """Takes a sensor id and returns the corresponding sensor.

        @type s_id: str
        @param s_id: ID of sensor to return
        @rtype: Dingo.sensor.Sensor
        """
        return self.s_list[s_id]
    
    def run(self):
        """Runs the simulation.
        This method runs the simulation and must be available to allow the project to work
        """
        if DEBUG: print 'Simulation.run iterating over sensor list'
        for s in self.s_list.itervalues():
            s.start()
        return

    ### Network topology generation for dingo-top

    def generateTopology(self):
        """Return list of sensors and topology info.

        File format:

        Sensor ID,x-coord,y-coord,Radio range
        <str>,<float>,<float>,<float>

        @rtype: list
        """
        width, height = self.graph.GetSizeTuple()
        top = [] # Topology
        top.append('Sensor ID,x-coord,y-coord,Radio range')
        if DEBUG: print 'Simulation.generateTopology iterating over sensor list'
        for sensor in self.s_list:
            x, y = self.s_list[sensor].coords
            r = self.s_list[sensor].ether.RF_radius
            data = ''
            data += sensor + ',' + str(x) + ',' + str(y) + ',' + str(r)
            top.append(data)
        return top

    ### Dynamically change network topology
    
    def gridifyTopology(self):
        """Gridify the topology of the currently running sensors.
        """
        numnodes = len(self.s_list)
        width, height = self.graph.GetSizeTuple()
        g_row = int(math.sqrt(numnodes)) # Num nodes in a row
        h_gap = width / g_row
        v_gap = height / (g_row + 1)
        x, y, col = 0, 0, 0
        if DEBUG: print 'Simulation.gridifyTopology iterating over sensor list'
        for sensor in self.s_list:
            if col == 0: # Start a new row on the grid
                col = g_row
                y += v_gap
                x = 0
            Publisher().sendMessage((consts.MOVE_TOPIC), {'id':sensor, 'coords':(x,y)})
            x += h_gap
            col -= 1
        return

    def randomiseTopology(self):
        """Randomise the topology of the currently running sensors.
        Uses a uniform distribution.
        """
        width, height = self.graph.GetSizeTuple()
        if DEBUG: print 'Simulation.randomiseTopology iterating over sensor list'
        rand = random.randrange # Inline optimisation
        for sensor in self.s_list:
            Publisher().sendMessage((consts.MOVE_TOPIC), {'id':sensor, 'coords':(random.randrange(width), random.randrange(height))})
        return

    def gaussianiseTopology(self):
        """Randomise the topology of the currently running sensors.
        Uses a Gaussian distribution.
        """
        width, height = self.graph.GetSizeTuple()
        if DEBUG: print 'Simulation.gaussianiseTopology iterating over sensor list'
        gaussW = lambda : random.gauss(width/2, width * 0.2)
        gaussH = lambda : random.gauss(height/2, height * 0.2)
        for sensor in self.s_list:
            Publisher().sendMessage((consts.MOVE_TOPIC), {'id':sensor, 'coords':(gaussW(), gaussH())})
        return

    def loadTopology(self, topology):
        """Load a topology from a string.

        Topology format:

        Sensor ID,x-coord,y-coord,Radio range
        <str>,<float>,<float>,<float>

        @type topology: str
        @param topology: String defining a network topology
        """
        for line in topology[1:]:
            data = line.split(',')
            if not data[0] in self.s_list:
                continue
            Publisher().sendMessage((consts.MOVE_TOPIC), {'id':str(data[0]), 'coords':(float(data[1]), float(data[2]))})
            self.s_list[data[0]].ether.RF_radius = float(data[3])
        return

    ### Network packet logging
    ### TODO: add error handling, etc.

    def startPacketLogger(self, projectPath=None):
        """Begin logging network transmissions."""
        if self.packetLogger is None:
            self.packetLogger = PacketLogger(projectPath)
        else:
            self.packetLogger.startLogging()
        return

    def stopPacketLogger(self):
        """Stop logging network transmissions."""
        if self.packetLogger is None:
           raise AssertionError, 'No packet logger in simulation'
        self.packetLogger.stopLogging()
        return
        

class PacketLogger:
    """Class for logging details of radio transmissions.

    File format:
    
    PktSource,PktDest,TimeSent,TimeRecvd,TOF,RSSI
    <str>,<str>,<float>,<float>,<float>,<float>
    """

    packetLogFile = 'messages.csv'
    """cvar: File on disk to write logged messages."""

    def __init__(self, projectPath):
        self.projectPath = projectPath
        self.logging = False
        self.log = None
	self.log_l = Lock()
        self.startLogging()
        return

    def startLogging(self, projectPath=None):
        """Start logging radio transmissions."""
        if not projectPath is None:
        	self.projectPath = projectPath
        self.logging = True
        if self.log is None or self.log.closed:
            self.log = open(self.projectPath + os.sep + PacketLogger.packetLogFile, 'w')
            self.log_l.acquire()
            self.log.write('PktSource,PktDest,TimeSend,TimeRecvd,TOF,RSSI,Dropped\n')
            self.log_l.release()
        return

    def stopLogging(self):
        """Stop logging radio transmissions."""
        self.logging = False
        if not self.log is None and not self.log.closed:
            self.log.close()
        return

    def write(self, src, dest, tsent, trcvd, tof, rssi, dropped):
        """Write an individual log message to disk.

        @type src: str
        @param src: Message sender
        @type dest: str
        @param dest: Message destination (None if broadcast message)
	@type tsent: float
        @param tsent: Time message was sent
        @type trcvd: float
        @param trcvd: Time message was received
        @type tof: float
        @param tof: Time of flight
        @type rssi: float
        @param rssi: Received signal strength indicator
	@type dropped: int
	@param dropped: Whether or not the packet has been dropped
        """
        if (not self.logging) or self.log.closed or self.log is None:
            return
        if dest is not None:
            entry = string.join((src, dest, str(tsent), str(trcvd), str(tof), str(rssi), str(dropped)), ',')
        else:
            entry = string.join((src, 'BCAST', str(tsent), str(trcvd), str(tof), str(rssi), str(dropped)), ',')
	self.log_l.acquire()
        self.log.write(entry + '\n')
       	self.log_l.release()
        return


class Ether(object):
    """Superclass that represents radio transmission.
    
    This class represents the transmission medium used by the sensors, and deals with message passing 
    between sensors. 
    """
    def __init__(self, sim, rf_ranger=ranger.NaiveRFRanger()):
        """Initialise a new Ether object.

        @type sim: Dingo.simulation.Simulation
        @param sim: The simulation the ether is attached to
        @type rf_ranger: Dingo.ranger.RFRanger
        @param rf_ranger: The ranger used by the (simulated) transmission medium
        """        
        self.sim = sim # typeof(sim) = Simulation
        self.rf_ranger = rf_ranger # typeof(sim) = RFRanger
        self.RF_radius = 200
	self.hasGUI = True
	Publisher().subscribe(self.onAttachGUI, (consts.ATTACH_TOPIC))	
	Publisher().subscribe(self.onDetachGUI, (consts.DETACH_TOPIC))		
        return

    def onAttachGUI(self, msg):
        self.hasGUI = True
        return

    def onDetachGUI(self, msg):
        self.hasGUI = False
        return
        
    def get_sensor(self, sensor_id):
        """Return a sensor within the simulation

        @rtype: Dingo.sensor.Sensor
        """
        return self.sim.get_sensor(sensor_id)

#    def broadcast(self, sender_id, arc_colour, time_sent=None, msg=None):
    def broadcast(self, msg, arc_colour):#, time_sent=None, msg=None):

        """Broadcast a message to sensors within radius, excluding
        sender.
        Broadcast radius is set in api.Ether.RF_radius.
        If no message is passed to broadcast it tries to find one
        on the send_q of the sending sensor. If that queue is empty
        broadcast returns.
        Messages that are sent are shallow copies of the original
        message.

        @type sender_id: str
        @param sender_id: The ID of the sending sensor
        @type arc_colour: Dingo.OGL.SensorColour
        @param arc_colour: The colour the message arcs are on the sensorGraph object
        @type time_sent: float
        @param time_sent: Time the message was sent
        @type msg: Dingo.packet.Packet
        @param msg: The message to transmit.
        """
        if msg == None: return

        destnodes = []
        for r in self.reachable_sensors(msg.sender_id):
            self.__send(copy.copy(msg), msg.sender_id, r)
            destnodes.append(self.get_sensor(r).id)

        if self.sim.packetLogger.logging:
            self.sim.packetLogger.write(msg.sender_id,
                                        'BCAST',
                                        msg.time_sent,
                                        '',#msg.time_received,
                                        '',#msg.time_of_flight,
                                        '',#msg.rssi,
					0
                                       )

	if self.hasGUI: 
		attribs = {'sender':msg.sender_id, 'receivers':destnodes,
                	   'bcast':True, 'colour':arc_colour, 'isdropped':False}
		Publisher().sendMessage((consts.RADIO_TOPIC), attribs)	
        return

    def multicast(self, sender_id, arc_colour, time_sent=None, msg=None):
        """Multicast a message to the specified sensors.
        
        Multicast allows the user to send a message to a list of specified sensors, providing these sensors
        are within the range defined in the Ether.  
        
        The list of sensors to send the message to is derived from the msg packets receiver_id field.
        This should be a list or tuple containing the intended receivers ID's
        
        As per Broadcast messages are if no message is specified a
        check is made to the Queue. Messages that are sent are shallow
        copies of the original message.

	Contributed by Daniel Goldsmith.

        @type sender_id: str
        @param sender_id: The ID of the sending sensor
        @type arc_colour: Dingo.OGL.SensorColour
        @param arc_colour: The colour the message arcs are on the sensorGraph object
        @type time_sent: float
        @param time_sent: Time the message was sent
        @type msg: Dingo.packet.Packet
        @param msg: The message to transmit
        """
        sender = self.get_sensor(sender_id)
        destination = msg.receiver_id # type list

        if msg == None:
            sender = self.get_sensor(sender_id)
            if not sender.send_q.empty():
                msg = sender.send_q.get()
            else :
                return
        reachable = self.reachable_sensors(sender_id)
        finalDestination = []
        for i in destination:
            if i in reachable:
                finalDestination.append(i)
        for r in finalDestination: # Is this nonsense or what?
            self.__send(copy.copy(msg), sender_id, r)

	if self.hasGUI: 
		attribs = {'sender':sender.id,
                	   'receivers':[self.get_sensor(d).id for d in finalDestination],
                	   'bcast':False, 'colour':arc_colour, 'isdropped':False}
		Publisher().sendMessage((consts.RADIO_TOPIC), attribs)
        return
        

    def reachable_sensors(self, sensor_id):
        """Returns a list of all sensor ids within a given radius.
        This checks for all sensors that are within transmission range of a given node.

        @type sensor_id: str
        @param sensor_id:  The sensor we wish to have at the centre of our range calculations
        @rtype: list
        """
        reachable = []
	(x1, y1) = self.get_sensor(sensor_id).coords
        for i in self.sim.s_list.keys():
            if not sensor_id == i:
                (x2, y2) = (self.get_sensor(i)).coords
                if self.__dist((x1, y1),(x2, y2)) <= self.RF_radius:
                    reachable.append(i)
        return reachable
        
        
    def send(self, msg, arc_colour):
        """Send a message to a single sensor.
        
        This places a message on the receive Queue of a given sensor.
        
	If a sensor tries to send to an unreachable peer a message is
	placed in the panel of the debug plugin to log the attempt.

        @type msg: Dingo.packet.Packet
        @param msg: The message to send
        @type arc_colour: Dingo.OGL.SensorColour
        @param arc_colour: The colour of the transmission arc shown on the graph
        """
        ## Get sensors
        sender = self.get_sensor(msg.sender_id)
        dest = self.get_sensor(msg.receiver_id)
        ## Get sensor locations
        (x1, y1) = sender.coords
        (x2, y2) = dest.coords

        isdropped = False

	## Check reachability
        if self.__dist((x1,y1), (x2,y2)) > self.RF_radius:
            sender.debug('RADIO: Tried to send to unreachable sensor' + msg.receiver_id)
            isdropped = True

        if not isdropped:
            ## Set RSSI
            msg.rssi = self.rf_ranger.get_rssi((x1, y1), (x2, y2))
            ## Set timestamp and time-of-flight
            msg.time_of_flight = self.rf_ranger.get_tof((x1, y1), (x2, y2))
            msg.time_received = msg.time_sent + msg.time_of_flight
            ## Send
            try:
            	dest.recv_q.put(msg)
            except Queue.Full:
            	isdropped = True
        
        ## Packet logging
        if self.sim.packetLogger.logging and not isdropped:
            self.sim.packetLogger.write(msg.sender_id,
                                        msg.receiver_id,
                                        msg.time_sent,
                                        msg.time_received,
                                        msg.time_of_flight,
                                        msg.rssi,
					0
                                       )
        elif self.sim.packetLogger.logging and isdropped:
            self.sim.packetLogger.write(msg.sender_id,
                                        msg.receiver_id,
                                        msg.time_sent,
                                        0,#msg.time_received,
                                        0,#msg.time_of_flight,
                                        0,#msg.rssi,
					1
                                       )

	## Tell the simulator to draw an arc
	if self.hasGUI: 
		attribs = {'sender':sender.id, 'receivers':[dest.id],
                	   'bcast':False, 'colour':arc_colour, 'isdropped':isdropped}
		Publisher().sendMessage((consts.RADIO_TOPIC), attribs)

        return

    def __send(self, msg, sender_id, recv_id):
        """Special send function for broadcasting.
	Reachability should be checked by the calling function.
        
        @type msg: Dingo.packet.Packet
        @param msg: The message to transmit
        @type sender_id: str
        @param sender_id: The ID of the sending sensor
        @type time_sent: float
        @param time_sent: Time the message was sent
        """
        ## Get sensors
        sender = self.get_sensor(sender_id)
        dest = self.get_sensor(recv_id)
        ## Get sensor locations
        (x1, y1) = sender.coords
        (x2, y2) = dest.coords
        ## Set timestamp and RSSI
        msg.rssi = self.rf_ranger.get_rssi((x1, y1), (x2, y2))
        ## Set time-of-flight
        msg.time_of_flight = self.rf_ranger.get_tof((x1, y1), (x2, y2))
        msg.time_received = msg.time_sent + msg.time_of_flight
        ## Send
        try:
            dest.recv_q.put(msg)
            isdropped = False
        except Queue.Full:
            isdropped = True

        ## Packet logging
        # FIXME: Should write one single log line for a broadcast
#         if self.sim.packetLogger.logging:
#             self.sim.packetLogger.write(msg.sender_id,
#                                         msg.receiver_id,
#                                         msg.time_sent,
#                                         msg.time_received,
#                                         msg.time_of_flight,
#                                         msg.rssi
#                                        )        
        return

    
    def __dist(self, (x1, y1), (x2, y2)):
        """Determine the Euclidean 2D distance between two nodes.

	TODO: Memoize this function.

        @rtype: float
        """
        return math.sqrt(math.pow(x2 - x1, 2)+math.pow(y2 - y1, 2))

if __name__ == '__main__': # Only for unit testing
    pass
